Popular Searches
Popular Course Categories
Popular Courses

Applying Repository and Data Source Patterns

Applying Repository and Data Source Patterns

iOS Architecture and State Management

Applying Repository and Data Source Patterns in iOS Development

Course: iOS App Development

Topic: Applying Repository and Data Source Patterns

Course Link: iOS App Development Training

Demo Link: Register for a Free Course Demo

Website: JustAcademy


1. Introduction

Modern iOS applications often retrieve and manage data from multiple sources, including remote APIs, local databases, cached responses, and files stored on the device. When all data-handling logic is placed directly inside view controllers or SwiftUI views, the application can become difficult to test, maintain, and extend.

The Repository Pattern and Data Source Pattern help organize data access into separate responsibilities. A repository provides a consistent interface for retrieving and saving data, while data sources handle the specific mechanisms used to access information, such as a REST API or a local database.

These patterns are especially useful in applications that display API-driven lists, support offline access, synchronize local and remote data, or need to replace one storage technology with another without rewriting the user interface.

2. Learning Objectives

  • Understand repository and data source patterns in iOS development.
  • Differentiate between repositories, remote data sources, and local data sources.
  • Implement data access layers using Swift protocols and structures.
  • Integrate URLSession for remote API communication.
  • Separate persistence logic from presentation logic.
  • Use dependency injection to improve testability.
  • Handle loading, errors, caching, and offline scenarios.
  • Apply the patterns in SwiftUI or UIKit applications.

3. What Is the Repository Pattern?

The Repository Pattern introduces an abstraction between the rest of an application and the underlying data-access mechanisms. Instead of asking a view or view model to call an API or query a database directly, the application requests data through a repository.

The repository determines where the data should come from and how it should be stored. Depending on the application's requirements, it may use a remote data source, a local data source, a cache, or a combination of them.

Example Without a Repository

Imagine a product screen that directly creates a URL request, decodes JSON, and updates the interface. This design combines networking, parsing, error handling, and presentation responsibilities in one place.

func loadProducts() {
    guard let url = URL(string: "https://example.com/api/products") else {
        return
    }

    URLSession.shared.dataTask(with: url) { data, response, error in
        // Handle errors, decode data, and update the UI.
    }.resume()
}

This approach may work for a small demonstration, but it becomes harder to reuse and test as the application grows.

Example With a Repository

func loadProducts() async {
    do {
        let products = try await productRepository.fetchProducts()
        // Update the view model's state.
    } catch {
        // Present an appropriate error state.
    }
}

The view model now depends on an abstraction rather than knowing how requests are sent or where products are stored.

4. What Is the Data Source Pattern?

The Data Source Pattern separates the implementation of data access according to the origin or storage mechanism of the data. Each data source is responsible for a particular task.

  • Remote data source: Retrieves or sends data through a web service or API.
  • Local data source: Reads and writes data in persistent storage, such as Core Data, SwiftData, SQLite, or files.
  • Cache data source: Keeps temporary or frequently accessed information available for faster retrieval.
  • Mock data source: Supplies predictable sample data for testing and previews.

Data sources should focus on access and storage details. They should not be responsible for controlling navigation, displaying alerts, or deciding how a screen is laid out.

5. Repository Pattern vs. Data Source Pattern

Aspect Repository Data Source
Primary responsibility Coordinates data access and exposes a consistent interface. Performs a specific data-access operation.
Knowledge of storage May coordinate multiple sources. Knows the details of its own source.
Examples ProductRepository, UserRepository ProductAPIDataSource, ProductLocalDataSource
Typical consumers View models and domain services Repositories
Testing benefit Allows view models to use a fake repository. Allows API or persistence behavior to be tested independently.
Change isolation Helps prevent storage changes from affecting presentation code. Contains source-specific implementation details.

6. Recommended Application Architecture

A common arrangement is to keep the user interface, presentation logic, repository, and data sources in separate layers.

SwiftUI View / UIKit View Controller
                 |
                 v
             ViewModel
                 |
                 v
             Repository
              /      \
             v        v
      Remote Source  Local Source
             |        |
             v        v
          Web API   Database/Cache

Important: This is a recommended arrangement, not a mandatory architecture. A small app may need fewer layers. Add abstractions when they clarify responsibilities, improve testing, or support real requirements.

7. Define the Data Model

Start by defining the type that represents the data used by the application. In this example, a product contains an identifier, a name, and a price.

import Foundation

struct Product: Codable, Identifiable, Equatable {
    let id: Int
    let name: String
    let price: Double
}

Conforming to Codable allows the type to be encoded or decoded when its properties match the data format. Identifiable makes it convenient to display products in SwiftUI lists, and Equatable helps compare values in tests.

In a production application, the API response model may differ from the domain model. In that case, use a separate DTO (Data Transfer Object) and map it into the model used by the application.

8. Define a Remote Data Source Protocol

Protocols define the operations a data source must support without tying the rest of the application to a specific implementation.

protocol ProductRemoteDataSource {
    func fetchProducts() async throws -> [Product]
}

This protocol allows the application to use a URLSession-based implementation in production and a mock implementation in tests.

9. Implement the Remote Data Source Using URLSession

The remote data source is responsible for creating the request, performing network communication, validating the HTTP response, and decoding the returned data.

import Foundation

enum APIError: Error {
    case invalidURL
    case invalidResponse
    case httpStatus(Int)
}

struct ProductAPIDataSource: ProductRemoteDataSource {
    let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func fetchProducts() async throws -> [Product] {
        guard let url = URL(
            string: "https://example.com/api/products"
        ) else {
            throw APIError.invalidURL
        }

        let (data, response) = try await session.data(from: url)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw APIError.invalidResponse
        }

        guard (200...299).contains(httpResponse.statusCode) else {
            throw APIError.httpStatus(httpResponse.statusCode)
        }

        return try JSONDecoder().decode([Product].self, from: data)
    }
}

The example URL is a placeholder and must be replaced with the actual API endpoint. The API must return JSON compatible with the Product model for this decoding logic to work.

Responsibilities of This Implementation

  • Builds the endpoint URL.
  • Uses asynchronous networking so the caller does not block the main thread while waiting for the response.
  • Checks whether the response is an HTTP response.
  • Validates the HTTP status code.
  • Decodes JSON into an array of products.
  • Propagates errors to the repository or view model for handling.

10. Define a Local Data Source

A local data source handles persistence or retrieval from device storage. The following protocol demonstrates the operations a local product store might provide.

protocol ProductLocalDataSource {
    func fetchCachedProducts() async throws -> [Product]
    func saveProducts(_ products: [Product]) async throws
}

For a production implementation, these methods could use SwiftData, Core Data, SQLite, or a file-based format. The chosen storage technology should be hidden behind the local data source interface so the repository does not need to know the implementation details.

For example, a SwiftData implementation would typically use a model context and persistent model types. If the application uses Core Data, it would use managed object contexts and entities. These implementations require their own persistence models, mapping, and error handling.

11. Implement the Repository

The repository combines data sources and provides a single interface for the rest of the application. A simple online-first repository can request products from the API and return the result.

protocol ProductRepository {
    func fetchProducts() async throws -> [Product]
}

struct DefaultProductRepository: ProductRepository {
    let remoteDataSource: any ProductRemoteDataSource

    func fetchProducts() async throws -> [Product] {
        try await remoteDataSource.fetchProducts()
    }
}

This first implementation intentionally stays simple. The repository can later coordinate caching, offline fallback, synchronization, or mapping without forcing the view model to know about those changes.

Repository With Local Cache

When local storage is available, a repository can save successful remote results and return cached data if the network request fails. The policy should be explicit because stale data may not be appropriate for every type of application.

struct CachedProductRepository: ProductRepository {
    let remoteDataSource: any ProductRemoteDataSource
    let localDataSource: any ProductLocalDataSource

    func fetchProducts() async throws -> [Product] {
        do {
            let products = try await remoteDataSource.fetchProducts()
            try await localDataSource.saveProducts(products)
            return products
        } catch {
            do {
                let cached = try await localDataSource.fetchCachedProducts()
                if !cached.isEmpty {
                    return cached
                }
            } catch {
                // Preserve the original remote error.
            }

            throw error
        }
    }
}

This example attempts to use cached products when the remote request or saving operation fails. For a stricter implementation, separate network-fetch errors from cache-write errors so a successful network response is not discarded merely because caching failed. You may also define a policy for whether empty cached results are valid, how old cache entries can be, and whether callers need to know that the data is stale.

12. Use the Repository in a ViewModel

The view model manages presentation state and asks the repository for data. It should not construct HTTP requests or directly manage database queries.

import Foundation

@MainActor
final class ProductViewModel: ObservableObject {
    @Published private(set) var products: [Product] = []
    @Published private(set) var isLoading = false
    @Published private(set) var errorMessage: String?

    private let repository: any ProductRepository

    init(repository: any ProductRepository) {
        self.repository = repository
    }

    func loadProducts() async {
        isLoading = true
        errorMessage = nil
        defer { isLoading = false }

        do {
            products = try await repository.fetchProducts()
        } catch {
            errorMessage = "Unable to load products. Please try again."
        }
    }
}

The @MainActor annotation ensures that the view model's isolated state is accessed on the main actor. The asynchronous repository call can suspend while network work proceeds, allowing the interface to remain responsive. Avoid performing expensive synchronous processing on the main actor.

13. Display Repository Data in SwiftUI

A SwiftUI view observes the view model and presents loading, error, empty, and success states. This keeps the user interface focused on presentation.

import SwiftUI

struct ProductListView: View {
    @StateObject private var viewModel: ProductViewModel

    init(repository: any ProductRepository) {
        _viewModel = StateObject(
            wrappedValue: ProductViewModel(repository: repository)
        )
    }

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView("Loading products...")
                } else if let error = viewModel.errorMessage {
                    VStack(spacing: 12) {
                        Text(error)
                            .multilineTextAlignment(.center)

                        Button("Try Again") {
                            Task {
                                await viewModel.loadProducts()
                            }
                        }
                    }
                    .padding()
                } else if viewModel.products.isEmpty {
                    ContentUnavailableView(
                        "No Products",
                        systemImage: "shippingbox",
                        description: Text("No products are available yet.")
                    )
                } else {
                    List(viewModel.products) { product in
                        HStack {
                            Text(product.name)
                            Spacer()
                            Text(product.price, format: .currency(code: "USD"))
                        }
                    }
                }
            }
            .navigationTitle("Products")
            .task {
                await viewModel.loadProducts()
            }
        }
    }
}

The example uses USD formatting for demonstration; select the currency appropriate to the application's requirements. The repository should be created and supplied by a composition root or dependency container, rather than repeatedly constructed inside the view.

14. Dependency Injection

Dependency injection means providing a component with the objects it needs instead of making it create those objects internally. Here, the view model receives a repository through its initializer.

let remoteSource = ProductAPIDataSource()
let repository = DefaultProductRepository(
    remoteDataSource: remoteSource
)

let viewModel = ProductViewModel(repository: repository)

Benefits include:

  • Different implementations can be supplied for development, testing, and production.
  • View models do not need to know how repositories are constructed.
  • Tests can use predictable fake data without a real network connection.
  • Implementation changes can be localized to the composition root or dependency container.

15. Testing the Repository and ViewModel

Repository and data source abstractions make unit testing easier because tests can replace real network or persistence components with controlled implementations.

struct MockProductRepository: ProductRepository {
    let result: [Product]

    func fetchProducts() async throws -> [Product] {
        result
    }
}

A view model test can inject the mock repository and verify that the products appear in its state after loading. Additional mock implementations can throw errors to test failure states.

Suggested Test Cases

  • Successful API response returns the expected product models.
  • Invalid JSON produces a decoding error.
  • Non-success HTTP status codes are handled correctly.
  • Network failures trigger the expected fallback or error state.
  • Cached products are returned when the selected offline policy allows it.
  • Saving cached data does not unexpectedly hide a successful remote result.
  • The view model resets its loading state after both success and failure.
  • Retrying a failed request updates the displayed state correctly.

16. Error Handling Best Practices

Errors should be handled at the layer that has enough context to respond appropriately. A data source can report technical failures, a repository can apply data-source fallback policies, and a view model can convert failures into user-facing state.

Error Possible Handling
Invalid URL or configuration Report a configuration error and correct the endpoint.
Network unavailable Use valid cached data if policy permits, or show a retry option.
HTTP 401 or 403 Handle authentication or authorization requirements.
HTTP 404 Handle a missing resource appropriately.
HTTP 500-range status Show a temporary service error and consider controlled retries.
JSON decoding failure Log diagnostic details safely and handle an unexpected response format.
Local persistence failure Report or log the storage issue without silently losing important data.

17. Caching and Offline-First Strategies

A repository is a natural place to coordinate a caching policy, but caching should be designed around the app's requirements.

  • Network-first: Attempt to fetch current data, then fall back to cached data when allowed.
  • Cache-first: Return cached data quickly, then refresh from the server when appropriate.
  • Stale-while-revalidate: Display existing data immediately while fetching a newer version.
  • Offline-first: Treat local storage as the primary source for reads and writes, synchronizing changes with the server when connectivity is available.

Consider cache expiration, data freshness, conflict resolution, pagination, synchronization failures, and how the UI communicates stale or offline data. Do not assume that a successful local save means a remote server has also been updated.

18. Common Mistakes to Avoid

  • Putting all logic in the view: Move networking and persistence details into dedicated components.
  • Creating a repository that adds no value: Keep the abstraction useful and avoid unnecessary layers for very small features.
  • Mixing UI behavior into a data source: Data sources should not present alerts or navigate between screens.
  • Ignoring HTTP status codes: A completed network request does not necessarily indicate a successful API operation.
  • Swallowing errors: Avoid returning empty arrays for every failure because this makes errors indistinguishable from valid empty results.
  • Using a mock as production storage: Clearly separate testing implementations from real data sources.
  • Unclear cache policies: Define when cached data is valid and when it should be refreshed.
  • Overusing singletons: Prefer explicit dependencies when they improve control and testability.
  • Blocking the main thread: Avoid synchronous network access and heavy decoding or database work on the main actor.

19. Practical Project: Product Catalog App

Build a product catalog application that retrieves products from a remote API and optionally keeps a local copy for offline use.

Project Requirements

  1. Create a Product model.
  2. Define remote and local data source protocols.
  3. Implement the remote source using URLSession.
  4. Implement local persistence using SwiftData, Core Data, SQLite, or another suitable store.
  5. Create a repository that coordinates remote and local data access.
  6. Inject the repository into a view model.
  7. Display loading, success, empty, and error states in SwiftUI.
  8. Add retry behavior and a defined cache policy.
  9. Write unit tests with mock data sources and repositories.

Expected Outcome

The application should display products without coupling the interface to URLSession or database implementation details. Developers should be able to replace the remote source, change local storage, or test the view model with mock data while minimizing changes to other layers.

20. Interview Questions and Answers

Q1. What is the Repository Pattern?

Answer: It provides a consistent interface for accessing and managing data while hiding the details of the underlying storage or service.

Q2. What is a Data Source Pattern?

Answer: It organizes data-access logic into components responsible for a specific source, such as a remote API, local database, or cache.

Q3. What is the main difference between a repository and a data source?

Answer: A data source performs source-specific operations. A repository exposes application-facing data operations and can coordinate multiple data sources.

Q4. Why should a view model depend on a repository protocol?

Answer: It reduces coupling and allows the view model to use real, mock, or alternative repository implementations.

Q5. How does dependency injection help testing?

Answer: It allows tests to supply controlled dependencies, so tests can run without relying on a live API or production database.

Q6. Where should HTTP response validation happen?

Answer: It commonly belongs in the remote data source, which understands HTTP responses and status codes.

Q7. Should a repository always return cached data when a network request fails?

Answer: No. It depends on the application's freshness and consistency requirements. Cached data should be used only when the defined policy allows it.

Q8. How can these patterns support offline functionality?

Answer: A repository can coordinate local persistence and remote synchronization, choosing when to read, write, refresh, or queue changes for later synchronization.

Q9. What should the view model do when a repository call fails?

Answer: It should update presentation state appropriately, such as showing an error message and retry action, while keeping low-level networking details out of the view.

Q10. Are repository and data source patterns mandatory in every iOS app?

Answer: No. They are useful when they clarify responsibilities or improve testability and maintainability. The design should match the size and complexity of the application.

21. Quick Revision Checklist

  • Understand the difference between repository and data source responsibilities.
  • Define protocols before adding concrete implementations where abstraction is useful.
  • Keep URLSession and database details out of the user interface.
  • Inject dependencies through initializers or a dependency container.
  • Validate HTTP responses and handle decoding failures.
  • Define cache and offline behavior explicitly.
  • Test success, failure, retry, and fallback scenarios.
  • Keep UI state management in the presentation layer.

22. Conclusion

The Repository and Data Source Patterns help iOS developers build applications that are easier to maintain, test, and extend. Data sources encapsulate specific access mechanisms, while repositories provide a consistent interface and coordinate data retrieval, persistence, caching, or offline behavior when required.

By combining these patterns with Swift protocols, asynchronous networking, dependency injection, and focused unit tests, developers can keep presentation code independent from storage details and adapt the application as its requirements change.


Explore more iOS training resources:

Previous Next
whatsapp